Skip to content

fix(flows): save-safety — no silent live-arming on update, flag empty runs, close resume_flow_run HITL bypass#4889

Merged
graycyrus merged 2 commits into
tinyhumansai:mainfrom
graycyrus:fix/flows-save-safety
Jul 15, 2026
Merged

fix(flows): save-safety — no silent live-arming on update, flag empty runs, close resume_flow_run HITL bypass#4889
graycyrus merged 2 commits into
tinyhumansai:mainfrom
graycyrus:fix/flows-save-safety

Conversation

@graycyrus

@graycyrus graycyrus commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Three safety fixes in the shared flows domain (all main-compatible, no #4881 dependency), surfaced by live E2E testing + a bug-sweep of the workflow-builder subsystem.

Problems & fixes

1. flows_update could silently arm a live auto-trigger. flows_create forces auto-trigger graphs born-disabled (B29 Rule 1), but flows_update preserved enabled=true — so editing a manual/enabled flow's graph to a schedule/webhook/app_event trigger armed it live, unreviewed (observed: a flow went live on an 8am schedule the user never saw). Fix: flows_update now disarms (enabled=false) when now_auto && existing.enabled && !was_auto (manual→automatic transition on an enabled flow), threaded into the same optimistic-concurrency-guarded UPDATE (no TOCTOU). Reuses the existing trigger_is_automatic predicate.

2. Running an empty/trigger-only flow reported "completed" doing nothing. Fix: graph_has_actionable_nodes guard in flows_runwarn! + a note on the result ("nothing to execute"); status shape unchanged (non-breaking).

3. CRITICAL — resume_flow_run HITL bypass. FLOWS_BUILD_HIDDEN_TOOLS only hid run_workflow/run_flow, but the widened builder belt includes resume_flow_run (external_effect=true). Under flows_build's Cli-origin auto-allow, a copilot authoring turn could resume a live run and fire its approved Slack/Gmail/HTTP nodes with zero human confirmation — reopening the hole PR #4593 closed. Fix: hide resume_flow_run + cancel_flow_run from the builder belt. create_workflow/duplicate_flow left visible (forced born-disabled; commented).

Testing

  • cargo test --lib openhuman::flows::365 passed, 0 failed.
  • New tests: manual→auto disarm, already-auto preserved, manual-target preserved, empty-flow note (+negative), and the belt-hiding test extended to assert resume_flow_run/cancel_flow_run are hidden while create_workflow/duplicate_flow stay visible.
  • GGML_NATIVE=OFF cargo check + cargo fmt --check clean.

Acceptance criteria

  • Repro gone — no silent live-arming on update; empty runs flagged; resume_flow_run not on the builder belt
  • Regression safety — 5+ new tests across the three fixes
  • Diff coverage ≥ 80% on changed lines

Summary by CodeRabbit

  • Bug Fixes

    • Empty flows now clearly report that no actionable steps were available instead of appearing to complete without performing work.
    • Enabling automatic triggers on an already-enabled manual flow now automatically disables it to prevent unintended runs.
    • Existing automatic and manual flow updates preserve their enabled state appropriately.
  • Safety Improvements

    • Flow building now restricts access to live-run controls that could affect active executions.

…pty-flow runs

flows_update had no equivalent of flows_create's B29 Rule 1: saving a graph
with an automatic trigger (schedule/app_event/webhook) onto an already
enabled flow whose previous trigger was manual/none would arm it live with
no user review (confirmed live: a flow started firing on an unreviewed 8am
schedule). flows_update now force-disables the flow in that manual->automatic
transition case only, threaded into the same guarded store write to avoid a
TOCTOU window.

Also: flows_run on a graph with no actionable nodes (trigger only, or no
edges wiring up any downstream node) reported a plain "completed" with no
indication nothing happened. It now logs a warning and attaches a
human-readable "note" to the run result.
flows_build runs workflow_builder under AgentTurnOrigin::Cli, which makes
the ApprovalGate auto-allow every external_effect tool. Issue tinyhumansai#4881 added
resume_flow_run (external_effect=true, "ADVANCES A REAL RUN") and
cancel_flow_run to the workflow_builder belt without extending
FLOWS_BUILD_HIDDEN_TOOLS, reopening the tinyhumansai#4593 HITL hole: an authoring turn
could approve a live run's parked Slack/Gmail/HTTP node with zero human
confirmation. Add both to the hide-list alongside run_flow/run_workflow.
create_workflow/duplicate_flow stay visible — both are forced born-disabled,
so an unattended call can't leave anything live.
@graycyrus
graycyrus requested a review from a team July 15, 2026 11:00
@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Flow execution now reports trigger-only runs, flow edits can atomically disable newly automatic enabled flows, and the direct builder path hides additional live-run controls.

Changes

Flow safety and execution behavior

Layer / File(s) Summary
Atomic enabled-state persistence
src/openhuman/flows/store.rs, src/openhuman/flows/store_tests.rs
update_flow_graph accepts an optional enabled override and persists it within the guarded graph update.
Trigger-transition safety
src/openhuman/flows/ops.rs, src/openhuman/flows/ops_tests.rs
Enabled manual-trigger flows are auto-disabled when changed to automatic triggers, with outcome logs; other trigger transitions preserve enabled state.
Empty-flow run reporting
src/openhuman/flows/ops.rs, src/openhuman/flows/ops_tests.rs
Trigger-only runs receive a no-actionable-nodes note, while actionable graphs do not.
Builder tool restrictions
src/openhuman/flows/ops.rs, src/openhuman/flows/ops_tests.rs
The direct builder path hides resume and cancel tools alongside live-run tools while retaining authoring and read tools.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant flows_update
  participant update_flow_graph
  participant flow_definitions
  Client->>flows_update: submit trigger-changing flow update
  flows_update->>flows_update: detect enabled manual-to-automatic transition
  flows_update->>update_flow_graph: pass enabled_override=false
  update_flow_graph->>flow_definitions: guarded update of graph and enabled state
  flow_definitions-->>Client: return updated disabled flow and logs
Loading

Possibly related PRs

Suggested labels: rust-core, bug

Suggested reviewers: senamakel

Poem

I’m a rabbit guarding flows tonight,
Empty runs now explain their plight.
Triggers change? Disable with care,
Hide live controls from builder air.
Bouncy tests make safety bright! 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the three main safety fixes: guarded live-arming, empty-run notes, and hiding resume/cancel flow-run tools.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot added bug rust-core Core Rust runtime in src/: CLI, core_server, shared infrastructure. labels Jul 15, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8eef793c31

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +2531 to +2532
let should_disarm = now_auto && existing.enabled && !was_auto;
let enabled_override = should_disarm.then_some(false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Compute disarm from the row being updated

When flows_update starts from a disabled manual flow and another request enables it before update_flow_graph rereads the row (the canvas and save_workflow both call this without expected_version), should_disarm stays false from the stale outer read. The store then preserves the newly true enabled value while saving the automatic trigger and the later rebind path arms it, leaving the same silent live-arming race this change is meant to close. The disarm decision needs to be made against the current row used by the guarded update, or force enabled=false for manual/none → automatic transitions regardless of the stale existing.enabled.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/openhuman/flows/store_tests.rs (1)

94-102: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add direct tests for both enabled_override branches.

Both calls pass None, so these tests do not verify that Some(false) persists the override or that None leaves the database column untouched. Add store-level regression coverage for both contracts.

As per coding guidelines, “New or changed behavior must include tests.”

Also applies to: 201-209

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/openhuman/flows/store_tests.rs` around lines 94 - 102, Add store-level
regression tests around update_flow_graph covering both enabled_override
contracts: verify Some(false) persists the disabled override, and verify None
leaves the existing database column unchanged. Keep the current None-based
update coverage, and use distinct setup/assertions for each branch.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/openhuman/flows/ops_tests.rs`:
- Around line 269-302: Add a test alongside
flows_run_on_graph_with_actionable_nodes_has_no_empty_flow_note with a trigger
and a separate downstream component whose non-trigger nodes are edge-connected
but unreachable from the trigger. Run the graph through flows_create and
flows_run, then assert the outcome includes the existing “nothing to execute”
note, covering unreachable actionable components.
- Around line 646-698: Add a deterministic concurrency regression test alongside
flows_update_disables_on_manual_to_automatic_trigger_transition_when_enabled:
pause flows_update after its initial flow read, enable the disabled manual flow
through the normal update path, then resume the pending update with an automatic
trigger graph. Assert the automatic graph is persisted disabled, the
response/logs reflect auto-disabling, and no schedule job is bound; coordinate
the interleaving with synchronization primitives rather than timing sleeps.

In `@src/openhuman/flows/ops.rs`:
- Around line 378-384: The graph_has_actionable_nodes function must traverse
from the trigger and return true only when a non-trigger node is reachable,
rather than merely counting non-trigger nodes and any edges. In
src/openhuman/flows/ops.rs:378-384, update this reachability check; in
src/openhuman/flows/ops_tests.rs:269-302, add a disconnected component
containing edges and assert that the empty-flow note still appears.

---

Outside diff comments:
In `@src/openhuman/flows/store_tests.rs`:
- Around line 94-102: Add store-level regression tests around update_flow_graph
covering both enabled_override contracts: verify Some(false) persists the
disabled override, and verify None leaves the existing database column
unchanged. Keep the current None-based update coverage, and use distinct
setup/assertions for each branch.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 36c9638b-334b-43b1-b709-6a55f7fba9ee

📥 Commits

Reviewing files that changed from the base of the PR and between ad771fd and 8eef793.

📒 Files selected for processing (4)
  • src/openhuman/flows/ops.rs
  • src/openhuman/flows/ops_tests.rs
  • src/openhuman/flows/store.rs
  • src/openhuman/flows/store_tests.rs

Comment on lines +269 to +302
/// A graph with a real downstream node, wired up by an edge, must NOT carry
/// the "nothing to run" note — only a graph with no actionable nodes at all.
/// Uses `output_parser` nodes (like the approval-gated fixture above) rather
/// than an `agent`/`tool_call` node so the run completes deterministically
/// without needing a configured LLM provider or network access.
#[tokio::test]
async fn flows_run_on_graph_with_actionable_nodes_has_no_empty_flow_note() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);

let graph = json!({
"name": "has-work",
"nodes": [
{ "id": "t", "kind": "trigger", "name": "Trigger" },
{ "id": "downstream", "kind": "output_parser", "name": "Downstream" }
],
"edges": [
{ "from_node": "t", "to_node": "downstream" }
]
});
let created = flows_create(&config, "has-work".to_string(), graph, false)
.await
.unwrap();

let outcome = flows_run(&config, &created.value.id, json!({}), FlowRunTrigger::Rpc)
.await
.unwrap();

assert!(
outcome.value.get("note").is_none(),
"a graph with real downstream nodes must not get the empty-flow note, got: {:?}",
outcome.value.get("note")
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Cover an edged but unreachable downstream component.

Add a fixture where non-trigger nodes have edges among themselves but no path from the trigger. It should still receive the “nothing to execute” note; the current implementation would miss it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/openhuman/flows/ops_tests.rs` around lines 269 - 302, Add a test
alongside flows_run_on_graph_with_actionable_nodes_has_no_empty_flow_note with a
trigger and a separate downstream component whose non-trigger nodes are
edge-connected but unreachable from the trigger. Run the graph through
flows_create and flows_run, then assert the outcome includes the existing
“nothing to execute” note, covering unreachable actionable components.

Source: Coding guidelines

Comment on lines +646 to +698
#[tokio::test]
async fn flows_update_disables_on_manual_to_automatic_trigger_transition_when_enabled() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);

// A manual-trigger flow persists enabled straight from create (Rule 1
// only gates automatic triggers).
let created = flows_create(
&config,
"manual-then-scheduled".to_string(),
manual_trigger_graph(),
false,
)
.await
.unwrap();
assert!(created.value.enabled, "manual-trigger flows create enabled");

// Saving an automatic-trigger graph onto that enabled flow must disarm
// it — not go live unattended.
let updated = flows_update(
&config,
&created.value.id,
None,
Some(schedule_trigger_graph("0 8 * * *")),
None,
None,
)
.await
.unwrap();

assert!(
!updated.value.enabled,
"an enabled flow whose trigger just changed from manual to automatic must be \
auto-disabled, not armed live"
);
assert!(
updated.logs.iter().any(|l| l.contains("auto-disabled")),
"the disarm must be surfaced in the outcome logs, got: {:?}",
updated.logs
);

// Persisted, not just returned in-memory.
let reloaded = flows_get(&config, &created.value.id).await.unwrap();
assert!(!reloaded.value.enabled);

// And no cron job was left bound — the flow never actually went live.
assert!(
crate::openhuman::cron::find_flow_schedule_job(&config, &created.value.id)
.unwrap()
.is_none(),
"an auto-disabled flow must not have its schedule cron job bound"
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Exercise the concurrent-enable transition.

This sequential test passes despite the stale-snapshot race in flows_update. Add a deterministic regression where a disabled manual flow is enabled between the initial read and guarded graph write; the automatic graph must never persist as enabled.

As per coding guidelines, “New or changed behavior must include tests.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/openhuman/flows/ops_tests.rs` around lines 646 - 698, Add a deterministic
concurrency regression test alongside
flows_update_disables_on_manual_to_automatic_trigger_transition_when_enabled:
pause flows_update after its initial flow read, enable the disabled manual flow
through the normal update path, then resume the pending update with an automatic
trigger graph. Assert the automatic graph is persisted disabled, the
response/logs reflect auto-disabling, and no schedule job is bound; coordinate
the interleaving with synchronization primitives rather than timing sleeps.

Source: Coding guidelines

Comment on lines +378 to +384
pub(crate) fn graph_has_actionable_nodes(graph: &WorkflowGraph) -> bool {
let non_trigger_nodes = graph
.nodes
.iter()
.filter(|n| n.kind != NodeKind::Trigger)
.count();
non_trigger_nodes > 0 && !graph.edges.is_empty()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== file map ==\n'
git ls-files 'src/openhuman/flows/*' | sed -n '1,120p'

printf '\n== outline ops.rs ==\n'
ast-grep outline src/openhuman/flows/ops.rs --view expanded || true

printf '\n== outline ops_tests.rs ==\n'
ast-grep outline src/openhuman/flows/ops_tests.rs --view expanded || true

printf '\n== relevant snippets ==\n'
sed -n '340,430p' src/openhuman/flows/ops.rs
printf '\n--- tests ---\n'
sed -n '240,340p' src/openhuman/flows/ops_tests.rs

Repository: tinyhumansai/openhuman

Length of output: 28321


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== usages of graph_has_actionable_nodes ==\n'
rg -n "graph_has_actionable_nodes" src/openhuman/flows -S

printf '\n== flows_run note handling ==\n'
sed -n '2990,3095p' src/openhuman/flows/ops.rs

printf '\n== graph validation for connectivity / reachability ==\n'
rg -n "connected|reachable|edge.*trigger|trigger.*edge|disconnected" src/openhuman/flows/ops.rs src/openhuman/flows/*.rs -S

Repository: tinyhumansai/openhuman

Length of output: 18674


Walk the graph from the trigger before suppressing the empty-flow note
non_trigger_nodes > 0 && !graph.edges.is_empty() still treats disconnected edges as actionable. Require at least one reachable non-trigger node instead.

  • src/openhuman/flows/ops_tests.rs: add a disconnected component with edges and assert the empty-flow note still appears.
📍 Affects 2 files
  • src/openhuman/flows/ops.rs#L378-L384 (this comment)
  • src/openhuman/flows/ops_tests.rs#L269-L302
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/openhuman/flows/ops.rs` around lines 378 - 384, The
graph_has_actionable_nodes function must traverse from the trigger and return
true only when a non-trigger node is reachable, rather than merely counting
non-trigger nodes and any edges. In src/openhuman/flows/ops.rs:378-384, update
this reachability check; in src/openhuman/flows/ops_tests.rs:269-302, add a
disconnected component containing edges and assert that the empty-flow note
still appears.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug rust-core Core Rust runtime in src/: CLI, core_server, shared infrastructure.

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant